I’m on a client project that’s using Devise. In an effort to prevent anonymous users from accessing admin routes, we wrap those routes with an authenticated constraint. This constraint also ensures only authenticated users who are admins are allowed access.
# config/routes.rb
authenticated :user, -> { _1.admin? } do
namespace :admin do
resources :users
end
end
We recently needed to restrict access to the admin routes based on IP address. Our first approach was to place this logic at the controller layer and use a before_action filter.
# app/controllers/admin/users_controller.rb
class Admin::UsersController < ApplicationController
before_action :authorize_ip
private
def authorize_ip
allow_list = Rails.application.config.x.ips.allow_list
raise ActionController::RoutingError.new("Not Found") unless requests.ip.in? allow_list
end
end
However, we realized there was an opportunity to push this logic to the routing layer, since we already have access to the request object. This saves us from having to process the request in a controller altogether, which is a small performance gain.
Create a custom constraint
Although Rails provides the ability to restrict routes based on IP range, we needed to create a custom constraint in order to see if the IP was in our allow list, which is not possible otherwise.
The custom constraint needs to respond to matches?
when passed a request,
and must return a boolean.
To see if a request is from an IP address in our allow list, we can do something like this:
# app/constraints/ip_constraint.rb
class IpConstraint
def self.matches?(request)
allow_list = Rails.application.config.x.ips.allow_list
request.ip.in? allow_list
end
end
Then we can wrap our admin routes in this constraint like so.
# config/routes.rb
authenticated :user, -> { _1.admin? } do
constraints(IpConstraint) do
namespace :admin do
resources :users
end
end
end
Consolidating constraints
Although the previous implementation is perfectly acceptable, there’s an
opportunity to consolidate the authenticated constraint with our
IpConstraint
.
Since we need access to the user
, we can leverage warden (which is a
dependency of Devise) to return the user object from the request.
requst.env["warden"].user
# => #<User>
We can then combine this with the logic used to check the IP address like so:
# app/constraints/admin_constraint.rb
class AdminConstraint
attr_reader :user, :ip
def initialize(request)
@user = request.env["warden"].user
@ip = request.ip
end
def self.matches?(request)
new(request).authorized?
end
def authorized?
allow_list = Rails.application.config.x.ips.allow_list
ip.in?(allow_list) && user.present? && user.admin?
end
end
A constraint needs to respond to matches?
, so we are free to put whatever
logic we want in that method so long as it returns boolean. In this
case, our matches?
method initializes a new instance of our constraint and
calls authorized?
. The authorized?
method is responsible for determining if
the request came from a supported IP address, and that the requested came from
an authenticated admin.
Now we can update our routes like so:
# config/routes.rb
constraints(AdminConstraint) do
namespace :admin do
resources :users
end
end
Wrapping up
I think this is an appropriate strategy for authorizing requests at the routing layer (instead of the controller layer) because it is only concerned with data in the request.
If you need data beyond the raw request, then you should leverage authorization libraries such as Pundit.
Want to learn more?
Learn about the Ruby on Rails services thoughtbot offers and how we can work together to streamline your project.